Modelo analítico: Predicción de Fuga de Clientes

Jaime Paz

2022-03-30

Contexto del negocio

En este problema, se nos ha presentado una base de datos de 78,829 registros donde se encuentra información de una empresa colombiana que otorga créditos. Esta corresponde a una base de datos histórica durante los últimos 30 meses.

El negocio desea estimar un modelo predictivo que permita estimar la probabilidad de fuga de cada cliente para la cartera de créditos proporcionada. El objetivo es crear un modelo de aprendizaje automático que permita clasificar dicha fuga de clientes para cada registro.

Un modelo de predicción de fuga permitirá a la empresa colombiana, el poder monetizar la predicción obtenida y estimar una posible perdida monetaria en la cartera de créditos. Adicional a esto, se pretende estimar cuales son las variables que mueven esta predicción, de tal manera que el negocio podrá enfocarse en dichos factores con el fin de reducir la perdida en la cartera de crédito.

Posterior al diseño del modelo predictivo, la empresa colombiana esta interesada en segmentar a dichos clientes de acuerdo a probabilidad de fuga, y esto permitirá generar campañas de marketing para fortalecer la relación con los clientes, en la cartera de crédito presente.

Fuente de datos

Se nos ha proporcionado una hoja de datos de excel (formato CSV) el cual tiene el nombre: Base de Datos Modelo.csv (raw data)

Herramientas utilizadas

Se utilizará el lenguaje de programación Python, ya que este cuenta con una amplia gama de paquetes que facilitan la exploración de datos. Adicional a esto, también consta de paquetes (Scikit-Learn) enfocados en machine learning, y para este caso se ponen en marcha algoritmos de clasificación (aprendizaje supervisado).

1.0. Importando paquetes

First we load reticulate package to write Python / R code in our Markdown enviroment:

library(reticulate)
Sys.setenv(RETICULATE_PYTHON = "/usr/local/bin/python3.7")
import pandas as pd              # paquete para data wrangling y exploracion
import numpy as np               # packate de algebra lineal
import seaborn as sns            # interfaz grafica de Python
import matplotlib.pyplot as plt  # interfaz grafica de Python
# adicionales:
import copy
import warnings
warnings.filterwarnings('ignore')
# paquete para aplicar clustering:
from sklearn.cluster import KMeans
pd.set_option('display.max_columns', 35)
pd.options.display.float_format = '{:.2f}'.format

2.0. Carga de datos

df = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Modelo.xlsx')
df.head()
##    CODIGO CLIENTE  CODIGO PRESTAMO  REGION  AGENCIA  PRODUCTO  SUBPRODUCTO  \
## 0           36253           362530      11       38        44            9   
## 1           36569           365690      11       38        44            9   
## 2           32082           320820       5      115        43            1   
## 3           59344           593440       5      115        40            1   
## 4           78551           785510      15      100        57            1   
## 
##                                      TIPO DE CREDITO  TASA_NOMINAL SEXO  \
## 0     C                                                      55.50    F   
## 1     C                                                      55.50    F   
## 2  C                                             ...         55.50    F   
## 3  C                                             ...         55.50    F   
## 4     D                                                      43.60    F   
## 
##    CAPITAL_CONCEDIDO  SALDO_CAPITAL ETAPA  CREDITOS ANTERIORES  \
## 0            1200.00         236.58    M1                    2   
## 1            1200.00         236.58    M1                    2   
## 2            1200.00         247.98    M1                    1   
## 3            1200.00         248.04    M1                    2   
## 4            1200.00         254.67    M1                    2   
## 
##              ESTADO  
## 0  Cliente Renovado  
## 1  Cliente Renovado  
## 2  Cliente Retirado  
## 3  Cliente Renovado  
## 4  Cliente Renovado

Despliegue de dimension:

print(df.shape)
## (78829, 14)

Tipos de datos cargados:

df.info()
## <class 'pandas.core.frame.DataFrame'>
## RangeIndex: 78829 entries, 0 to 78828
## Data columns (total 14 columns):
##  #   Column               Non-Null Count  Dtype  
## ---  ------               --------------  -----  
##  0   CODIGO CLIENTE       78829 non-null  int64  
##  1   CODIGO PRESTAMO      78829 non-null  int64  
##  2   REGION               78829 non-null  int64  
##  3   AGENCIA              78829 non-null  int64  
##  4   PRODUCTO             78829 non-null  int64  
##  5   SUBPRODUCTO          78829 non-null  int64  
##  6   TIPO DE CREDITO      78829 non-null  object 
##  7   TASA_NOMINAL         78829 non-null  float64
##  8   SEXO                 78829 non-null  object 
##  9   CAPITAL_CONCEDIDO    78829 non-null  float64
##  10  SALDO_CAPITAL        78829 non-null  float64
##  11  ETAPA                78829 non-null  object 
##  12  CREDITOS ANTERIORES  78829 non-null  int64  
##  13  ESTADO               78829 non-null  object 
## dtypes: float64(3), int64(7), object(4)
## memory usage: 8.4+ MB

Despliegue de variables numericas

df.describe() #variables numericas
##        CODIGO CLIENTE  CODIGO PRESTAMO   REGION  AGENCIA  PRODUCTO  \
## count        78829.00         78829.00 78829.00 78829.00  78829.00   
## mean         44701.61        447016.14     7.68    58.14     42.43   
## std          25598.41        255984.06     4.51    66.30      5.26   
## min              1.00            10.00     1.00     1.00     21.00   
## 25%          22630.00        226300.00     4.00    29.00     40.00   
## 50%          44863.00        448630.00     7.00    53.00     41.00   
## 75%          66832.00        668320.00    11.00    79.00     44.00   
## max          88828.00        888280.00    33.00   871.00     90.00   
## 
##        SUBPRODUCTO  TASA_NOMINAL  CAPITAL_CONCEDIDO  SALDO_CAPITAL  \
## count     78829.00      78829.00           78829.00       78829.00   
## mean          3.71         41.05           11582.48        4515.81   
## std           4.46          9.81           15437.91        9448.51   
## min           1.00          0.00            1200.00           0.00   
## 25%           1.00         30.00            3000.00         507.53   
## 50%           2.00         43.60            6000.00        1229.26   
## 75%           6.00         43.60           15000.00        3815.11   
## max          52.00         60.50          600000.00      294137.83   
## 
##        CREDITOS ANTERIORES  
## count             78829.00  
## mean                  3.45  
## std                   3.81  
## min                   1.00  
## 25%                   2.00  
## 50%                   3.00  
## 75%                   4.00  
## max                 106.00

No existen duplicados en los datos:

df[df.duplicated()] #no existen datos duplicados:
## Empty DataFrame
## Columns: [CODIGO CLIENTE, CODIGO PRESTAMO, REGION, AGENCIA, PRODUCTO, SUBPRODUCTO, TIPO DE CREDITO, TASA_NOMINAL, SEXO, CAPITAL_CONCEDIDO, SALDO_CAPITAL, ETAPA, CREDITOS ANTERIORES, ESTADO]
## Index: []

3.0. Data wrangling / Limpieza de datos

EXPLORACION DE VARIABLES CATEGORICAS

TIPO DE CREDITO:

df['TIPO DE CREDITO'] = df['TIPO DE CREDITO'].str.replace('[^A-Za-z0-9]+', '', regex=True)
100 * df['TIPO DE CREDITO'].value_counts() / df.shape[0]
## A   48.33
## C   26.97
## V   13.17
## D    6.19
## F    2.82
## E    1.62
## B    0.87
## G    0.04
## f    0.00
## Name: TIPO DE CREDITO, dtype: float64

Se puede ver que la mayoría de tipos de crédito corresponde a A, C y V. Como buena práctica (y no perjudicar los modelos analitos) agruparemos las demás categorías como “Otros”

def mapper(x):
    if x in ['A', 'C', 'V']:
        return x
    else:
        return 'Other'
      
df['TIPO DE CREDITO'] = df['TIPO DE CREDITO'].map(mapper)
df['TIPO DE CREDITO'].value_counts()
## A        38099
## C        21260
## V        10378
## Other     9092
## Name: TIPO DE CREDITO, dtype: int64

SEXO

#SEXO
df['SEXO'].value_counts()
# tenemos un valor en donde no se especifica el sexo:
## F    54654
## M    24174
##          1
## Name: SEXO, dtype: int64
df['SEXO'] = df['SEXO'].str.replace('[^A-Za-z0-9]+', 'invalido', regex=True)

ETAPA

df['ETAPA'].value_counts()
## M1    70530
## M2     4966
## M3     3333
## Name: ETAPA, dtype: int64

ESTADO


df['ESTADO'].value_counts()
## Cliente Renovado    48815
## Cliente Retirado    30014
## Name: ESTADO, dtype: int64
to_dummy = ['ETAPA', 'SEXO', 'TIPO DE CREDITO']
df['ESTADO'] = ['1' if x == "Cliente Retirado" else '0' for x in df['ESTADO'] ]
class NANS:
    def __init__(self, data):
        self._data = data
        
    def tot_nan(self):
        nulls_v = self._data.isnull().sum(axis = 1) >= 1
        #self._data['NA Flag'] = nulls_v.rename('NA Flag')
        df = pd.DataFrame((self._data.isna().sum())).reset_index().\
        merge( (100 * (self._data.isna().sum()) / self._data.shape[0]).round(3).\
        reset_index(), on = 'index').rename(columns = {'index': 'Feature', '0_x': 'Count', '0_y':'%'}).\
        sort_values( by = ['%'], ascending = False)
        df = df.merge(pd.DataFrame(self._data.dtypes).reset_index().rename(\
        columns = {'index':'Feature', 0:'Var Type'}), on = 'Feature')
        return df.reset_index().drop(["index"], axis=1)
nulls_df = NANS(df)
nulls_df.tot_nan() 
# NO TENEMOS VALORES NULOS:
##                 Feature  Count    % Var Type
## 0        CODIGO CLIENTE      0 0.00    int64
## 1       CODIGO PRESTAMO      0 0.00    int64
## 2                REGION      0 0.00    int64
## 3               AGENCIA      0 0.00    int64
## 4              PRODUCTO      0 0.00    int64
## 5           SUBPRODUCTO      0 0.00    int64
## 6       TIPO DE CREDITO      0 0.00   object
## 7          TASA_NOMINAL      0 0.00  float64
## 8                  SEXO      0 0.00   object
## 9     CAPITAL_CONCEDIDO      0 0.00  float64
## 10        SALDO_CAPITAL      0 0.00  float64
## 11                ETAPA      0 0.00   object
## 12  CREDITOS ANTERIORES      0 0.00    int64
## 13               ESTADO      0 0.00   object
class features:
    
    def __init__(self, data_f):
        self._df = data_f
    def select_if(self, x):
        if x == 'is.numeric':
            return self._df[self._df.select_dtypes(include = 'number' ).columns]
        elif x == 'is.character':
            return self._df[self._df.select_dtypes(exclude = 'number' ).columns]
        else:
            raise ValueError('Invalid value. Please provide: "is.numeric" or "is.character" only')
# distribucion de features
feats = features(df)
num_feats = feats.select_if('is.numeric')
cat_feats = feats.select_if('is.character')
# guardamos el dataframe en nueva variable: train
import copy

train = copy.deepcopy(df)

4.0. Analisis Exploratorio de Datos (EDA)

Exploracion de variables numericas: (boxplots)

fig = plt.figure(figsize=(20,20))

for index, item in enumerate(num_feats.columns, 1):
    plt.subplot(4, 3, index)
    sns.boxplot(y=train[item], x= train['ESTADO'] , hue= train['ESTADO'],
                  linewidth=2.5)
    plt.legend()
    

plt.show() 

Exploracion de variables numericas: (densities)

fig = plt.figure(figsize=(20,20))

for index, item in enumerate(num_feats.columns, 1):
    plt.subplot(4, 3, index)
    sns.distplot(train[train.ESTADO == '1'][item], color="red", hist = False,  kde=True, norm_hist=True)
    sns.distplot(train[train.ESTADO == '0'][item], color="blue", hist = False,  kde=True, norm_hist=True)
    plt.legend(labels=['Si','No'], title = 'Fuga??')

plt.show() 

Exploracion de variables numericas: (cummulative densities)

fig = plt.figure(figsize=(20,20))

for index, item in enumerate(num_feats.columns, 1):
    plt.subplot(4, 3, index)
    sns.kdeplot(train[train.ESTADO == '1'][item], color="red", cumulative = True)
    sns.kdeplot(train[train.ESTADO == '0'][item], color="blue", cumulative = True)
    plt.legend(labels=['Yes','No'], title = 'Fuga?')

plt.show() 

EXPLORACION DE CORRELACION: VARIABLES NUMERICAS

num_feats.corr()
##                      CODIGO CLIENTE  CODIGO PRESTAMO  REGION  AGENCIA  \
## CODIGO CLIENTE                 1.00             1.00   -0.09    -0.07   
## CODIGO PRESTAMO                1.00             1.00   -0.09    -0.07   
## REGION                        -0.09            -0.09    1.00     0.37   
## AGENCIA                       -0.07            -0.07    0.37     1.00   
## PRODUCTO                       0.09             0.09   -0.07    -0.23   
## SUBPRODUCTO                    0.07             0.07    0.07     0.17   
## TASA_NOMINAL                  -0.09            -0.09   -0.06    -0.03   
## CAPITAL_CONCEDIDO              0.15             0.15   -0.01    -0.05   
## SALDO_CAPITAL                  0.25             0.25   -0.04    -0.03   
## CREDITOS ANTERIORES            0.20             0.20   -0.12    -0.05   
## 
##                      PRODUCTO  SUBPRODUCTO  TASA_NOMINAL  CAPITAL_CONCEDIDO  \
## CODIGO CLIENTE           0.09         0.07         -0.09               0.15   
## CODIGO PRESTAMO          0.09         0.07         -0.09               0.15   
## REGION                  -0.07         0.07         -0.06              -0.01   
## AGENCIA                 -0.23         0.17         -0.03              -0.05   
## PRODUCTO                 1.00        -0.12          0.20              -0.03   
## SUBPRODUCTO             -0.12         1.00         -0.07               0.04   
## TASA_NOMINAL             0.20        -0.07          1.00              -0.42   
## CAPITAL_CONCEDIDO       -0.03         0.04         -0.42               1.00   
## SALDO_CAPITAL            0.05         0.11         -0.33               0.79   
## CREDITOS ANTERIORES      0.17         0.13         -0.25               0.20   
## 
##                      SALDO_CAPITAL  CREDITOS ANTERIORES  
## CODIGO CLIENTE                0.25                 0.20  
## CODIGO PRESTAMO               0.25                 0.20  
## REGION                       -0.04                -0.12  
## AGENCIA                      -0.03                -0.05  
## PRODUCTO                      0.05                 0.17  
## SUBPRODUCTO                   0.11                 0.13  
## TASA_NOMINAL                 -0.33                -0.25  
## CAPITAL_CONCEDIDO             0.79                 0.20  
## SALDO_CAPITAL                 1.00                 0.24  
## CREDITOS ANTERIORES           0.24                 1.00
f, ax = plt.subplots(figsize = (18,18))
sns.heatmap(num_feats.corr(), annot = True, linewidths=0.5, fmt = '.1f', ax = ax)
plt.show()

Se observa una correlación muy fuerte (0.8) entre CAPITAL_CONCEDIDO y SALDO_CAPITAL, esto se tendrá en consideración durante el desarrollo del modelo. Por otro lado, CODIGO_PRESTAMO y CODIGO_PRESTAMO no aportan valor al modelo, y se puede ver que están altamente correlacionadas.

EXPLORACION DE VARIABLES CATEGORICAS

def plot_cat(cats = to_dummy, a = 1, b = 1, c = 1 ):
    fig = plt.figure(figsize=(15,20))
    for i in cats:   
        grouped_df = train[['ESTADO', i ]] .groupby(['ESTADO', i]).size().to_frame('Percent')
        grouped_df['Percent'] = (grouped_df['Percent'] * 100 / sum(grouped_df['Percent'])).round(0)
        grouped_df = grouped_df.reset_index()
   
        plt.subplot(a, b, c)
        plt.title('{}, subplot: {}{}{}'.format(i, a, b, c))
        plt.xlabel(i)
        sns.barplot(x=i, y = 'Percent', hue='ESTADO', data=grouped_df)
        plt.legend(title = 'Fuga = 1')
        c = c + 1
    plt.show()
plot_cat(cats = to_dummy, a = 2, b = 2, c = 1)

4.1. INSIGHTS ENCONTRADOS

1. Tanto la variable CODIGO CLIENTE y CODIGO PRESTAMO no son significativas en el modelo, puesto que solamente corresponde a IDs únicos o llaves por cada cliente.

2. Dentro de la variable REGION se pueden encontrar un grupo de 0 – 5, se puede distinguir un grupo significativo de clientes que se dan a la fuga. No parece existir fuga persistente entre la región 16 y 30, sin embargo, se observan muchos valores atípicos arriba de 30 (tanto para clientes leales como clientes fugados)

3. Para la variable AGENCIA, existe sesgo a la derecha de clientes con fuga positiva, especialmente para los casos arriba 200. ¿Por qué la fidelidad de clientes no es muy remarcada en esta zona? Convendría investigar este hecho.

4. Conviene revisar los clientes fugados entre PRODUCTO = 20 a PRODUCTO = 30, ya que en dicha región no existen registros de clientes fieles a nuestra marca.

5. La mayor parte de créditos se concentra dentro de la TASA NOMINAL 40 y 50. Esto, para ambos grupos de fuga y no fuga.

6. La mayor preferencia respecto al CAPITAL CONCEDIDO se encuentra debajo de Q. 20,000; la cual afecta de manera significativa a ambos grupos fuga y no fuga.

7. La mayor parte del SALDO CAPITAL se mantiene debajo de Q5,000. Sin embargo, existe una diferencia significativa y pronunciada para aquellos que tienen a fugarse entre 0 y Q.4,000.

8. De acuerdo al histórico de CREDITOS ANTERIORES, la mayoría de clientes mantiene estos debajo de 5.

9. Existe una mayor presencia (60% de clientes no fugados y 30% fugados) en aquellos créditos cuya ETAPA es M1. Las etapas M2 y M3 no sobrepasan en 5% de casos, pero cabe destacar que solo se han registrado fuga de clientes en la categoría M3 (no existen clientes fieles).

10. La mayor parte de créditos se otorga a personas de sexo femenino y en general, las mujeres tienen a ser más fieles a nuestra marca, comparado con los hombres. Existe mayor oportunidad de negocio en los clientes con crédito TIPO A, registrando un total de 32% de casos, que son fieles a nuestra marca.

5.0. FEATURE ENGINEERING / DATA PREPARATION

De acuerdo a nuestro EDA, eliminamos variables que no aportan valor al modelo de prediccion de fuga. Por el momento, eliminamos CODIGO CLIENTE y CODIGO PRESTAMO

train.drop(['CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis = 1, inplace = True)
train.head()
##    REGION  AGENCIA  PRODUCTO  SUBPRODUCTO TIPO DE CREDITO  TASA_NOMINAL SEXO  \
## 0      11       38        44            9               C         55.50    F   
## 1      11       38        44            9               C         55.50    F   
## 2       5      115        43            1               C         55.50    F   
## 3       5      115        40            1               C         55.50    F   
## 4      15      100        57            1           Other         43.60    F   
## 
##    CAPITAL_CONCEDIDO  SALDO_CAPITAL ETAPA  CREDITOS ANTERIORES ESTADO  
## 0            1200.00         236.58    M1                    2      0  
## 1            1200.00         236.58    M1                    2      0  
## 2            1200.00         247.98    M1                    1      1  
## 3            1200.00         248.04    M1                    2      0  
## 4            1200.00         254.67    M1                    2      0

Para un modelo de regresión logística (como se ve más adelante), se requiere que las variables o features no posean mucha varianza (como se pudo ver en el EDA existe muchas de ellas con variables atípicos). Para esto se procede suavizar las variables mediante la aplicación de un logaritmo natural, así como también la normalización estándar.

feats = features(train)
num_feats = feats.select_if('is.numeric')

for column in num_feats:
    try:
        train[column] = np.log1p(train[column]) 
    except (ValueError, AttributeError):
        pass

train.head()
##    REGION  AGENCIA  PRODUCTO  SUBPRODUCTO TIPO DE CREDITO  TASA_NOMINAL SEXO  \
## 0    2.48     3.66      3.81         2.30               C          4.03    F   
## 1    2.48     3.66      3.81         2.30               C          4.03    F   
## 2    1.79     4.75      3.78         0.69               C          4.03    F   
## 3    1.79     4.75      3.71         0.69               C          4.03    F   
## 4    2.77     4.62      4.06         0.69           Other          3.80    F   
## 
##    CAPITAL_CONCEDIDO  SALDO_CAPITAL ETAPA  CREDITOS ANTERIORES ESTADO  
## 0               7.09           5.47    M1                 1.10      0  
## 1               7.09           5.47    M1                 1.10      0  
## 2               7.09           5.52    M1                 0.69      1  
## 3               7.09           5.52    M1                 1.10      0  
## 4               7.09           5.54    M1                 1.10      0

CODIFICACION DE VARIABLES CATEGORICAS A DUMMY

dummies = pd.get_dummies(train[cat_feats.columns], drop_first = True)
train_dummies = pd.concat([train, dummies], axis = 1)
train_dummies.drop(cat_feats.columns, axis = 1, inplace = True)
train_dummies.head()
##    REGION  AGENCIA  PRODUCTO  SUBPRODUCTO  TASA_NOMINAL  CAPITAL_CONCEDIDO  \
## 0    2.48     3.66      3.81         2.30          4.03               7.09   
## 1    2.48     3.66      3.81         2.30          4.03               7.09   
## 2    1.79     4.75      3.78         0.69          4.03               7.09   
## 3    1.79     4.75      3.71         0.69          4.03               7.09   
## 4    2.77     4.62      4.06         0.69          3.80               7.09   
## 
##    SALDO_CAPITAL  CREDITOS ANTERIORES  TIPO DE CREDITO_C  \
## 0           5.47                 1.10                  1   
## 1           5.47                 1.10                  1   
## 2           5.52                 0.69                  1   
## 3           5.52                 1.10                  1   
## 4           5.54                 1.10                  0   
## 
##    TIPO DE CREDITO_Other  TIPO DE CREDITO_V  SEXO_M  SEXO_invalido  ETAPA_M2  \
## 0                      0                  0       0              0         0   
## 1                      0                  0       0              0         0   
## 2                      0                  0       0              0         0   
## 3                      0                  0       0              0         0   
## 4                      1                  0       0              0         0   
## 
##    ETAPA_M3  ESTADO_1  
## 0         0         0  
## 1         0         0  
## 2         0         1  
## 3         0         0  
## 4         0         0

NORMALIZACION DE VARIABLES NUMERICAS

from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
columns_std = num_feats.columns
train_dummies[columns_std] = scaler.fit_transform(train_dummies[columns_std])
X = train_dummies.drop('ESTADO_1', axis=1)
y = train_dummies['ESTADO_1']

6.0. CONSTRUCCION DE MODELOS

Training / Testing Split (75% para train y 25% para validation)

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25, random_state=42)

CHEQUEO DE VARIABLE TARGET (DATOS BALANCEADOS O IMBALANCEADOS)

100 * y_train.value_counts() / len(y_train)
## 0   61.95
## 1   38.05
## Name: ESTADO_1, dtype: float64

Podemos confirmar que nuestros datos estan imbalanceados, por lo que necesitaremos el ajuste de class_weight para dar mayor peso a la categoria minoritaria (fuga de cliente, que equivale a 1).

6.1. REGRESION LOGISTICA (SIN REGULARIZACION)

from sklearn.metrics import classification_report, roc_auc_score, f1_score, precision_score, recall_score, auc, precision_recall_curve, roc_curve, confusion_matrix, make_scorer
from sklearn.metrics import precision_recall_fscore_support
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, RepeatedStratifiedKFold

NOTA: EL SIGUIENTE SET DE CODIGO NO SE EJECUTA, YA QUE ANTERIORMENTE SE HA PROCEDIDO A REALIZAR UN GRIDSEARCH PARA CONFIGURACION DE HIPERPRAMETROS. EL OBJETO GRIDSEARCH FUE GUARDADO EN UN ARCHIVO QUE POSTERIORMENTE SE HA CARGADO

## 1. Creacion de objeto log_reg y aplicar metodo LogisticRegression:

#log_reg = LogisticRegression()

## 2. Configuracion de solvers y weight class (tratar con clases imbalanceadas)

#solvers = ['newton-cg', 'lbfgs', 'liblinear', 'lbfgs'] # escoger el mejor solver

#weights = [{0:x, 1:1.0-x} for x in np.linspace(0.0,0.99,100)] # pesos

## 3. Almacenar hiperparametros en diccionario:

#param_grid = dict(solver = solvers, 
 #                 class_weight = weights)

## 4. Seleccionamos metricas para estimar accuracy: Precision, Recall y F1 Score

#scorers = {
#    'precision_score': make_scorer(precision_score),
#    'recall_score': make_scorer(recall_score),
#    'f1_score':     make_scorer(f1_score)
#}

## 5. HYPER PARAMETER TUNNING

#gridsearch = GridSearchCV(estimator= log_reg, 
#                          param_grid= param_grid,
#                          cv=StratifiedKFold(n_splits = 10), 
#                          n_jobs=-1, 
#                          scoring=scorers,
#                          refit= 'f1_score',   #we focus on the F1 metric to display the better results
#                          return_train_score=True,
#                          verbose=2).fit(X_train.values, y_train.values)

Fitting 10 folds for each of 400 candidates, totalling 4000 fits

import dill
# Save the file
#dill.dump(gridsearch, file = open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch.pickle", "wb"))
# Reload the file
gridsearch = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch.pickle", "rb"))

Despliegue de resultados: Logistic Regression:

y_pred = gridsearch.predict(X_test.values)
print('Best params for F1 score')
## Best params for F1 score
print(gridsearch.best_params_) # mejores parametros escogidos para solver y class_weight (segun metrica F1)
## {'class_weight': {0: 0.35000000000000003, 1: 0.6499999999999999}, 'solver': 'liblinear'}

CONFUSION MATRIX

def conf_matrix(y_test, log_reg_pred):    
    
    # Creating a confusion matrix
    con_mat = confusion_matrix(y_true=y_test, y_pred=log_reg_pred)
    con_mat = pd.DataFrame(con_mat, range(2), range(2))
   
    #Ploting the confusion matrix
    plt.figure(figsize=(6,6))
    sns.set(font_scale=1.5) 
    sns.heatmap(con_mat, annot=True, annot_kws={"size": 16}, fmt='g', cmap='Blues', cbar=False)
    # axis labels
    plt.xlabel('Predictions')
    plt.ylabel('Actuals')
    title = 'Confusion Matrix'.upper()
    plt.title(title, loc='center')
conf_matrix(y_test, y_pred)
plt.show()

Se puede observar que todavía existe espacio para mejora. Para los casos actuales de fuga, los valores pronosticados contienen 5,829 casos correctos, pero 1,689 casos incorrectos. Y para los casos donde los clientes son fieles (no fuga), se tiene un error de 1,394

REPORTE PRECISION AND RECALL:

precision_recall_fscore_support(y_test, y_pred, average='macro')
# PRECION, RECALL, F1: 
## (0.8388382707286938, 0.8523752346296698, 0.8432369566375288, None)

PRECISION: El falso positivo es un caso problemático para la institución colombiana. Ya que si un cliente que se encontraba en estado NO fugado y se ha pronosticado que SI se ha fugado, puede que incurra en problemas internos en ofrecer campañas innecesarias. Sin embargo, esto no es tan CRITICO como el falso negativo.

RECALL: Hace énfasis en capturar los POSITIVOS REALES y también da prioridad al falso negativo. Para la institución colombiana, es MUY CRITICO indicar que un cliente el cual se encontraba en ESTADO FUGADO, se ha pronosticado como un cliente no fugado. Esto representa una perdida monetaria a la institución, ya que esta dejando de monitorear aquellos casos positivos (fuga) y esto incurre en un costo para ella.

def plot_precision_recall_vs_threshold(precisions, recalls, thresholds):

    plt.figure(figsize=(8, 8))
    plt.title("Precision  / Recall Scores (decision threshold)")
    plt.plot(thresholds, precisions[:-1], "b--", label="Precision")
    plt.plot(thresholds, recalls[:-1], "g-", label="Recall")
    plt.ylabel("Score")
    plt.xlabel("Decision Threshold")
    plt.legend(loc='best')
y_scores = gridsearch.predict_proba(X_test)[:, 1]
p, r, thresholds = precision_recall_curve(y_test, y_scores)
plot_precision_recall_vs_threshold(p, r, thresholds)
plt.show()

Eligiendo un THRESHOLD O UMBRAL de aproximadamente 42% se obtiene un PRECISION y RECALL de aproximadamente 80%. Este umbral lo que indica, es que la probabilidad arriba de 42% se considera para el caso de un cliente FUGADO (1) y debajo de 42%, significa cliente NO FUGADO (0)

Eligiendo un umbral por debajo de 42% se obtiene beneficio con el RECALL. Por ejemplo, a un THRESHOLD de 40% se alcanza un RECALL de aproximadamente 83%, pero esto a costa de reducir la PRECISION a 77% aproximadamente.

6.2. MODELO RANDOM FOREST

CONFIGURACION DE HIPERPARAMETROS

from sklearn.ensemble import RandomForestClassifier

# PARA EL ALGORITMO DE RANDOM FOREST, NO ES NECESARIO NORMALIZAR LAS VARIABLES 
# NUMERICAS.

dummies = pd.get_dummies(df[cat_feats.columns], drop_first = True)
train_dummies = pd.concat([df, dummies], axis = 1)
train_dummies.drop(cat_feats.columns, axis = 1, inplace = True)

X = train_dummies.drop(['ESTADO_1', 'CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis=1)
y = train_dummies['ESTADO_1']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25, random_state=42)
## 1. Creacion de objeto rand_forest y aplicar metodo RandomForestClassifier:

#rand_forest = RandomForestClassifier(class_weight = {0: 0.35000000000000003, 1: 0.6499999999999999}, max_depth = 20)

## 2. Numero de arboles a crecer:

#n_estimators = [int(x) for x in np.linspace(start = 100, stop = 500, num = 21)]

## 3. Nivel maximo en cada arbol

#max_depth = [int(x) for x in np.linspace(10, 25 , num = 12)]

#random_grid = {'n_estimators': n_estimators }

## 4. Seleccionamos metricas para estimar accuracy: Precision, Recall y F1 Score

#scorers = {
#    'precision_score': make_scorer(precision_score),
#    'recall_score': make_scorer(recall_score),
#    'f1_score':     make_scorer(f1_score)
#}

## 5. HYPER PARAMETER TUNNING

#gridsearch = GridSearchCV(estimator= rand_forest, 
 #                         param_grid= random_grid,
  #                        cv=StratifiedKFold(n_splits = 10), 
   #                       n_jobs=-1, 
    #                      scoring=scorers,
     #                     refit= 'f1_score',   #we focus on the F1 metric to display the better results
      #                    return_train_score=True,
      #                    verbose=2).fit(X_train.values, y_train.values)
## GUARDAR MODELO

import dill

# Save the file
#dill.dump(gridsearch, file = open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch_3.pickle", "wb"))

# Reload the file
gridsearch = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch_3.pickle", "rb"))
y_pred = gridsearch.predict(X_test.values)
print('Best params for F1 score')
## Best params for F1 score
print(gridsearch.best_params_) # mejores parametros escogidos 
## {'n_estimators': 300}

RECALIBRANDO EL MODELO

random_forest = RandomForestClassifier(class_weight = {0: 0.35000000000000003, 1: 0.6499999999999999}, max_depth = 20, n_estimators= 300, random_state = 123)
                                       
random_forest.fit(X_train, y_train)
## RandomForestClassifier(class_weight={0: 0.35000000000000003,
##                                      1: 0.6499999999999999},
##                        max_depth=20, n_estimators=300, random_state=123)
y_pred = random_forest.predict(X_test.values)
conf_matrix(y_test, y_pred)
plt.show()

precision_recall_fscore_support(y_test, y_pred, average='macro')
# PRECION, RECALL, F1: 
## (0.895411817191262, 0.9028013598645722, 0.8986818222914237, None)
y_scores = random_forest.predict_proba(X_test)[:, 1]
p, r, thresholds = precision_recall_curve(y_test, y_scores)
plot_precision_recall_vs_threshold(p, r, thresholds)
plt.show()

results = pd.DataFrame({'precision': p[:-1], 'recall': r[:-1], 'th':thresholds }, columns=['precision', 'recall', 'th'])
results[(results.th >= 0.55) & (results.th <= 0.60) ]
##        precision  recall   th
## 9814        0.87    0.88 0.55
## 9815        0.87    0.88 0.55
## 9816        0.87    0.88 0.55
## 9817        0.87    0.88 0.55
## 9818        0.87    0.88 0.55
## ...          ...     ...  ...
## 10139       0.89    0.86 0.60
## 10140       0.88    0.86 0.60
## 10141       0.89    0.86 0.60
## 10142       0.89    0.86 0.60
## 10143       0.89    0.86 0.60
## 
## [330 rows x 3 columns]

El modelo muestra una mejora respecto al equilibrio entre la PRECISION Y RECALL, empleando un THRESHOLD de aproximadamente 58%. La gran ventaja de este modelo es que podemos lograr obtener un RECALL de 85%, empleando un THRESHOLD de 70% y sumado a esto, NO ponemos en riesgo de la PRECISION, ya que esta queda a un nivel de 80%.

El THRESHOLD optimo seleccionado sera:

precision = 0.89, recall = 0.85, threshold = 0.62

- Dado los dos modelos anteriores, el modelo de Random Forest sobre pasa en performance al modelo de Regresión Logística. Se pudo observar que es posible seleccionar un threshold de tal manera de no impactar en la PRECISION y el RECALL, considerando que esta última es crítica para un modelo de fuga.

- En términos de interpretabilidad, el modelo de regresión logística es más comprensible el poder explicarlo a una audiencia sin conocimientos técnicos y matemáticos. El algoritmo de random forest consta de una estructura de árboles de decisión, y debido a la complejidad matemática, puede ser difícil destapar la caja negra.

- Una desventaja de la regresión logística es que la relación de las variables predictoras y el predictor (fuga) debe ser lineal. Convendría hacer un estudio, del poder incluir variables adicionales que puedan mejorar su performance. Sin embargo, si lo que se requiere es precisión, se recomienda utilizar el modelo de random forest.

7.0. IMPORTANCIA DE VARIABLES

Para evaluar la importancia de variables, recurrimos a la métrica de Gini o la mejora de impureza (promedio):

random_forest.fit(X_train, y_train)
## RandomForestClassifier(class_weight={0: 0.35000000000000003,
##                                      1: 0.6499999999999999},
##                        max_depth=20, n_estimators=300, random_state=123)
std = np.std([tree.feature_importances_ for tree in random_forest.estimators_], axis=0)
importances = random_forest.feature_importances_
forest_importances = pd.Series(importances, index = X_train.columns)
fig, ax = plt.subplots()
forest_importances.plot.barh(yerr=std, ax=ax)
ax.set_title("Importancia de variables")
ax.set_ylabel("Mejora en nivel de impureza (media) - Gini")
plt.show()

Como se puede observar, las variables “CREDITOS ANTERIORES” y “SALDO CAPITAL” son las variables mas relevantes en el sistema de predicción de fuga de clientes. El “CAPITAL CONCEDIDO” queda en tercer lugar.

8.0. PREDICCION DE DATOS (HOJA DE CALCULO TEST)

CARGA DE DATOS

test_df = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Predicción.xlsx')

CONVERSION DE VARIABLES EN NUEVO DATAFRAME:

test_df['TIPO DE CREDITO'] = test_df['TIPO DE CREDITO'].str.replace('[^A-Za-z0-9]+', '', regex=True)
test_df['TIPO DE CREDITO'] = test_df['TIPO DE CREDITO'].map(mapper)
test_df['SEXO'] = test_df['SEXO'].str.replace('[^A-Za-z0-9]+', 'invalido', regex=True)
vars_valid = ['TIPO DE CREDITO', 'SEXO', 'ETAPA']
dummies = pd.get_dummies(test_df[vars_valid], drop_first = True)
val_dummies = pd.concat([test_df, dummies], axis = 1)
val_dummies.drop(vars_valid, axis = 1, inplace = True)
X_val = val_dummies.drop(['CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis=1)
X_val.drop(['Probabilidad Fuga', 'Predicción '], axis = 1, inplace = True)
X_val['SEXO_invalido'] = "0"
X_val.SEXO_invalido = X_val.SEXO_invalido.astype('uint8')

PREDICCION DE ESTADO:

y_pred_val = random_forest.predict(X_val.values)

PREDICCION DE PROBABILIDADES:

y_scores_val = random_forest.predict_proba(X_val)[:, 1]
output = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Predicción.xlsx')
output['Probabilidad Fuga'] = y_scores_val
output['Predicción '] = ['Cliente Retirado' if x >= 0.62 else 'Cliente Renovado' for x in output['Probabilidad Fuga']  ]

EXPORTANDO EL ARCHIVO

import pandas as pd
import openpyxl
#output.to_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Modelo (salida).xlsx', sheet_name='salida')

8.0. ANALISIS DE CLUSTERING

Con las 3 variables de importancia que fueron obtenidos con el algoritmo de Random Forest, se procede a seleccionar las variables: ‘CAPITAL_CONCEDIDO’, ‘SALDO_CAPITAL’, ‘CREDITOS ANTERIORES’ y ‘Probabilidad de Fuga’. Posteriormente se puede a tratar de construir un modelo de segmentación utilizando K-Means Clustering:

Carga de datos:

cluster = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/cluster.pickle", "rb"))

cluster_new = cluster.drop(['CODIGO CLIENTE', 'ETAPA_M2', 
                        'AGENCIA', 'SUBPRODUCTO', 'REGION', 'PRODUCTO', 'TASA_NOMINAL',
                        'ETAPA_M3', 'SEXO_M', 'TIPO DE CREDITO_Other', 'TIPO DE CREDITO_C', 
                        'SEXO_invalido', 'TIPO DE CREDITO_V'], axis = 1).values

X = StandardScaler().fit_transform(cluster_new)

CONSTRUCCION DE OBJETO KMEANS:

from sklearn.cluster import KMeans
wcss = []

for i in range(1,11):
    kmeans = KMeans(n_clusters= i, max_iter = 300, 
                    init='k-means++', random_state=123, 
                    algorithm='auto')
    kmeans.fit(X)
    wcss.append(kmeans.inertia_)
## KMeans(n_clusters=1, random_state=123)
## KMeans(n_clusters=2, random_state=123)
## KMeans(n_clusters=3, random_state=123)
## KMeans(n_clusters=4, random_state=123)
## KMeans(n_clusters=5, random_state=123)
## KMeans(n_clusters=6, random_state=123)
## KMeans(n_clusters=7, random_state=123)
## KMeans(random_state=123)
## KMeans(n_clusters=9, random_state=123)
## KMeans(n_clusters=10, random_state=123)

DESPLIEGUE: ELBOW METHOD:

plt.legend('')
plt.plot(range(1,11), wcss)
plt.title('Elbow Method')
plt.xlabel('No. of clusters')
plt.ylabel('wcss')
plt.show() 

kmeans_model = KMeans(n_clusters= 4, max_iter = 300, 
                    init='k-means++', random_state=123, 
                    verbose = 0, algorithm='auto')
cluster['y_pred'] = kmeans_model.fit_predict(X) 

De acuerdo con los resultados, seleccionamos 2 clusters de clientes (region apartir de donde los errores ya no caen drasticamente)

REDUCCION DE VARIABLES MEDIANTE PCA:

from sklearn.decomposition import PCA

pca = PCA(n_components = 2)
pca_fuga = pca.fit_transform(cluster)
pca_fuga_df = pd.DataFrame(data = pca_fuga, columns = ['Componente_1', 'Componente_2'])
pca_nombres_fuga = pd.concat([pca_fuga_df, cluster[['y_pred']]], axis = 1)
plt.figure(figsize=(10,6))

plot_clusters = sns.scatterplot(x=pca_nombres_fuga.Componente_1, 
                y=pca_nombres_fuga.Componente_2, hue=pca_nombres_fuga.y_pred,
                palette='Set1', s=100, alpha=0.2,  
                data=pca_nombres_fuga).set_title('KMeans Clusters (4)', fontsize=15)


plt.show()

Luego de analizar todas las variables proporcionadas, no fue posible encontrar una región muy remarcada que permita separar a los clientes utilizando el método de K-Means. Convendría analizar a futuro otro método que permita lograr una separación más eficiente.